The example presented in the previous post made a single call to the AI. A real chat needs four more things: dependency
injection so the client is a service, a middleware pipeline so behaviour like
logging and tool-invocation is composable, conversation memory so the model
remembers what was said, and system instructions so it behaves the way you want.
All four are built into Microsoft.Extensions.AI.
Register the client with DI
AddChatClient registers IChatClient in the service container and returns
a ChatClientBuilder so you can wrap it in middleware:
builder.Services.AddChatClient(static _ => new OllamaApiClient(
new Uri("http://127.0.0.1:11434"), "qwen3:1.7b"))
.UseLogging() // logs requests/responses to ILogger
.UseFunctionInvocation(); // runs tool calls -- see article 3
Anywhere downstream, you take an IChatClient in a constructor and DI supplies
the fully-decorated instance.
The pipeline is Russian dolls
Each Use... call wraps the client in a decorator that is itself an
IChatClient. A request travels outermost-first down to the real provider,
and the response travels back out. Because every layer implements the same
interface, you can add your own — caching, rate-limiting, redaction — the same
way the built-ins are added. The important built-ins:
UseLogging()— structured logs of each request and response.UseFunctionInvocation()— actually executes tool calls (article 3).UseOpenTelemetry()— traces and metrics for dashboards.UseDistributedCache()— caches identical requests.
Order matters: the last Use... you add is the outermost layer.
Something has to hold the conversation
Here is the single most common misunderstanding about chat APIs:
IChatClient is stateless. It does not remember anything between calls. Each
call sends the entire message list and gets one reply. If you want the model to
remember the previous turn, you must send the previous turns back every time.
So, we need a small service that owns the history. In the ChatClientServices.cs, I implement the following code:
public class ChatClientServices(IChatClient client)
{
readonly IChatClient _client = client;
readonly List<ChatMessage> _conversationHistory = [];
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
ChatMessage message, ChatOptions? options = null, CancellationToken token = default)
{
_conversationHistory.Add(message);
return _client.GetStreamingResponseAsync(_conversationHistory, options, token);
}
public void AddAssistantResponse(string text)
=> _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, text));
// ...
}
Two things to notice:
GetStreamingResponseAsyncsends the whole list, not just the new message.- There is a matching
AddAssistantResponse. Streaming gives you fragments; until you write the assembled reply back into the history as oneChatRole.Assistantmessage, the model has no record of what it just said. Forgetting this call is the classic “the bot has amnesia” bug.
System instructions
A ChatMessage has a Role: User, Assistant, System or Tool. The
system message is your standing instruction — tone, format, guardrails — and
it goes first and stays first:
public const string SystemInstructions =
"""
You are a concise assistant embedded in a .NET desktop application.
Answer in at most three sentences unless the user explicitly asks for detail.
When you reference code, use C# and prefer modern language features.
If you do not know something, say so plainly rather than guessing.
""";
ClearConversationHistory in the sample deliberately preserves system messages
while dropping the user/assistant turns — resetting the conversation should not
strip the personality:
public void ClearConversationHistory()
{
var systemMessages = _conversationHistory.Where(m => m.Role == ChatRole.System).ToList();
_conversationHistory.Clear();
_conversationHistory.AddRange(systemMessages);
}
ChatRole is a convention, not an enum
Those four roles are not something this sample invented, and they are not a
closed set either. ChatRole is a readonly struct wrapping a string, with
four values predefined in Microsoft.Extensions.AI.Abstractions:
ChatRole.System // "system"
ChatRole.User // "user"
ChatRole.Assistant // "assistant"
ChatRole.Tool // "tool"
Because it wraps a string and converts implicitly from one, new ChatRole("critic")
compiles perfectly well. Whether the provider on the other end does anything
useful with it is a different question — most will reject or ignore a role they
do not recognise, so in practice you stay with the four.
How standard are they? The system / user / assistant triad is the shared
vocabulary of essentially every chat API, so that part travels everywhere. The
others do not:
Toolmatches the OpenAI-styletoolrole used to return a function result. Anthropic’s Messages API has no such role at all: a tool result is a content block inside a user message.Systemis an ordinary message in the list for OpenAI-style APIs. For Anthropic, the system prompt is a top-level request parameter, not a message.
Which is exactly the point of the abstraction. The
m.Role == ChatRole.System filter above is still the right code to write
against any provider: you keep one uniform message list, and the concrete
IChatClient reshapes it when it builds the actual HTTP request. Roles are part
of the abstraction’s vocabulary, and each client is responsible for mapping
that vocabulary onto whatever its wire format expects.
The chat loop
Program.cs drives it from a simple console loop: read a line, stream the
reply, repeat. The part that matters:
var assistantText = string.Empty;
await foreach (var update in chat.GetStreamingResponseAsync(
new ChatMessage(ChatRole.User, input)))
{
Console.Write(update.Text);
assistantText += update.Text;
}
// Close the turn: without this the model forgets what it just said.
chat.AddAssistantResponse(assistantText);
Accumulate the streamed fragments into assistantText, render each one live,
then commit the whole thing back to history. That accumulate-render-commit
pattern reappears in every UI in this series.
Run it
dotnet run --project 02.ChatWithHistory
you> my name is Sam
ai > Nice to meet you, Sam.
you> what's my name?
ai > Your name is Sam.
you> clear
(conversation cleared)
you> what's my name?
ai > I don't have that information.
The memory works because every turn resends the growing list — and it resets on
clear because we emptied that list. There is no server-side session; you own
the state.
What you learned
AddChatClientregisters the client and opens a middleware pipeline.- The pipeline is composable decorators;
UseFunctionInvocationandUseLoggingare just the built-in ones. IChatClientis stateless — a service of yours holds the history and resends it each turn.- The system message carries behaviour and survives a conversation reset.
ChatRoleis a string-backed struct, not an enum — the four roles are a shared convention, and eachIChatClientmaps them onto its provider’s wire format.- Streaming means accumulate, render, then commit the assistant turn.enr